<?php
declare(strict_types=1);

namespace CommonBundle\Service;

use CommonBundle\Parser\ExpressionDqlParser;
use CommonBundle\Utils\ArrayCommon;
use CommonBundle\Utils\FilterDateTime;
use CommonBundle\Utils\Inflect;
use CommonBundle\Utils\Math;
use Doctrine\Common\Annotations\AnnotationReader;
use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\DBAL\Exception\UniqueConstraintViolationException;
use Doctrine\ORM\Mapping\ManyToMany;
use Doctrine\ORM\Mapping\ManyToOne;
use Doctrine\ORM\Mapping\OneToMany;
use Doctrine\ORM\Mapping\OneToOne;
use Doctrine\ORM\NonUniqueResultException;
use Doctrine\ORM\NoResultException;
use Doctrine\ORM\OptimisticLockException;
use Doctrine\ORM\ORMException;
use Doctrine\ORM\Query;
use Doctrine\ORM\Query\Expr;
use Doctrine\ORM\QueryBuilder;
use Monolog\Logger;
use Symfony\Component\Debug\Exception\FatalErrorException;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Security\Core\User\UserInterface;
use Symfony\Component\Validator\Exception\ValidatorException;


abstract class BaseService
{
    /** @var ContainerInterface */
    protected $container;
    /** @var \Doctrine\ORM\EntityManager|object */
    protected $em;
    /** @var \Doctrine\Common\Persistence\ObjectRepository|\Doctrine\ORM\EntityRepository */
    protected $rep;
    /** @var string */
    protected $data_class;
    /** @var Logger */
    protected $logger;
    /** @var UserInterface */
    protected $user;

    /**
     * BaseService constructor.
     * @param ContainerInterface $container
     * @param string $data_class
     */
    function __construct(ContainerInterface $container, string $data_class)
    {
        $this->container = $container;
        $this->data_class = $data_class;
        $this->em = $container->get('doctrine.orm.entity_manager');
        $this->rep = $this->em->getRepository($data_class);
        $this->logger = $container->get('logger');

        $tokenStorage = $this->container->get('security.token_storage');
        $token = $tokenStorage->getToken();
        $this->user = $token ? $token->getUser() : null;
    }

    /**
     * @param $list
     * @return ArrayCollection
     */
    public static function listResultToCollection($list): ArrayCollection
    {
        if($list instanceof QueryBuilder) {
            $result = $list->getQuery()->getResult();
            if($result) {
                return new ArrayCollection($result);
            }
        }
        elseif(is_array($list)) {
            return new ArrayCollection($list);
        }

        // Others
        return new ArrayCollection();
    }

    /**
     * @return array
     */
    public function externalExpressionValues(): array
    {
        return [
            'math' => new Math(),
            'datetime' => new FilterDateTime(),
            'Math' => new Math(),
            'Datetime' => new FilterDateTime(),
            'ArrayCommon' => new ArrayCommon(),
        ];
    }

    /**
     * @param $object
     * @param bool $disableRequest
     * @return null|object
     */
    public function get($object, bool $disableRequest = true)
    {
        // get object
        $entity = null;
        if ($object instanceof QueryBuilder) {
            try {
                $entity = $object->getQuery()->getSingleResult();
            } catch (NoResultException | NonUniqueResultException $e) {
                $entity = null;
            }
        }
        elseif (is_array($object)) {
            $entity = $this->rep->findOneBy($object);
        } else {
            $entity = $this->rep->find($object);
        }

        return $entity;
    }

    /**
     * @param null $object
     * @param null $order
     * @param bool $disableRequest
     * @return int|mixed|string
     * @throws \Exception
     */
    public function list(
        $object = null,
        $order = null,
        bool $disableRequest = true
    ) {
        /*
            // JS
            let query = {
                // Controller level
                'page': 1,
                'limit': 10,
                '@expands': "['category', 'template.category', 'items.specification']",
                '@display': "['id', 'category.name']"
                '@display': "{id: 'entity.getId()', 'category': 'entity.getCategory().getName()'}" // Display with expression

                // Database level
                '@order': 'entity.name | ASC, entity.id | DESC', // order by
                '@filter': 'entity.getUser().getProfile().getCreatedTime() > datetime.get("now") && entity.getCategory().getId() == 5',
                '@dql': 'SELECT p FROM MainBundle:Product p WHERE p.id = 2',
                '@hints': '{"doctrine.forcePartialLoad": true}',

                '@select': 'entity.status, SUM(entity.price) AS sum',
                '@groupBy': 'entity.status',

                // Service level
                // Special filter, low efficient
                '@sort': 'x.getId() > y.getId()',
                '@filter': 'entity.getCategories().count() > 10',
            }
        */

        // Get and parse request
        $em = $this->container->get('doctrine.orm.entity_manager');
        $request_stack = $this->container->get('request_stack');
        $request = $request_stack->getCurrentRequest();


        // Normal list

        // Transform to query builder
        // Query builder
        if($object instanceof QueryBuilder) {
            $qb = $object;

            // Get root alias
            $aliases = $object->getRootAliases();
            if(empty($aliases)) {
                throw new ValidatorException('Invalid query build aliases');
            }
            $alias = $aliases[0];
        }
        else {
            // Set root alias
            $alias = 'entity';

            $qb = $em->createQueryBuilder()
                ->select($alias)
                ->from($this->data_class, $alias)
            ;

            if(is_array($object)) {
                // Transform from $repository->find(['key' => $value]) to Query
                foreach ($object as $key => $value) {
                    $qb
                        ->andWhere("entity.$key = :value_$key")
                        ->setParameter("value_$key", $value)
                    ;
                }
            }
        }


        // Sub DQL
        if (!$disableRequest && ($subDql = $request->query->get('@dql'))) {
            $subDql = $em->createQuery($subDql);
            $qb->andWhere((new Expr())->in("$alias.id", $subDql->getDQL()));
        }

        // Database level filter
        $filterError = false;
        if (!$disableRequest && ($filter = $request->query->get('@filter'))) {
            // Backup current query builder
            $backupQb = clone $qb;
            try {
                $parser = new ExpressionDqlParser();
                $parser
                    ->setDataClass($this->data_class)
                    ->setExpression($filter)
                    ->setValues($this->externalExpressionValues())
                    ->compile();

                $filterDql = $em->createQuery($parser->getSource());

                $qb->andWhere((new Expr())->in("$alias.id", $filterDql->getDQL()));

                /** @var Query\Parameter $parameter */
                foreach ($parser->getParameters() as $parameter) {
                    $qb->setParameter($parameter->getName(), $parameter->getValue());
                }

                // Test query result
                $testQb = clone $qb;
                $testQb
                    ->setMaxResults(1)
                    ->getQuery()
                    ->getResult();
            }
            catch (\Exception $exception) {
                $this->logger->error('Filter exception: '. $exception->getMessage());
                $this->logger->error('Filter source: '. $filter);

                // Reverse
                $filterError = true;
                $qb = $backupQb;
            }
        }

        // Set object
        $object = $qb;

        // Join
        $joins = [];
        $joiner = function(string &$expression, array &$joins, string $rootAlias) {
            // Replace independence select/groupBy 'entity' to root alias
            // 1. Root alias replace pattern: /\w+((\.\w+)+)/g -> root_alias$1
            $expressionAlias = 'entity';
            $aliasPattern = "/$expressionAlias((\.\w+)+)/";
            $aliasReplacement = "$rootAlias$1";
            $expression = preg_replace($aliasPattern, $aliasReplacement, $expression);

            // 2. Match pattern: /(\w+\s*\.\s*)+\w+/g
            $joinPattern = '/(\w+\s*\.\s*)+\w+/';
            if(preg_match_all($joinPattern, $expression, $matches)) {
                foreach ($matches[0] as $item) {
                    $itemParts = explode('.', $item);
                    $joinKey = '';
                    foreach ($itemParts as $i => $match) {
                        if($i == 0) {
                            $joinKey = $match; continue;
                        }
                        $exportValue = $joinKey . '.' . $match;
                        $joinKey .= '_' . $match;

                        if($i >= count($itemParts) -1) break;
                        $joins[$joinKey] = $exportValue;
                    }
                }
            }

            // Translate select fields to correct style
            // 3. Normal replace pattern: /\.(\w+)(?=\.)/g -> _$1
            $expression = preg_replace('/\.(\w+)(?=\.)/', '_$1', $expression);
        };

        // Set select
        // Select demo: entity.user.profile.nickName AS nickName, SUM(entity.user.profile.balance) AS totalBalance
        $select = $request->query->get('@select');
        if (!$disableRequest && $select) {
            $joiner($select, $joins, $alias);
            $qb->select($select);
        }

        // Add group by
        // Group by demo: entity.user.profile, entity.user.enabled
        $groupBy = $request->query->get('@groupBy');
        if (!$disableRequest && $groupBy) {
            $joiner($groupBy, $joins, $alias);
            $qb->addGroupBy($groupBy);
        }

        // Concat orders
        // Order demo: entity.user.root | ASC, totalBalance | DESC
        // Replace order
        $preOrders = $request->query->get('@order');
        if (!$disableRequest && $preOrders) {
            $joiner($preOrders, $joins, $alias);

            $preOrders = explode(',', trim($preOrders));
            $order = [];

            foreach ($preOrders as $o) {
                $t = explode('|', $o);
                if (count($t) == 2) {
                    $order[trim($t[0])] = trim($t[1]);
                }
            }
        }
        if($order) {
            foreach ($order as $key => $value) {
                $object->addOrderBy($key, $value);
            }
        }

        // Combine joins
        // Create joins in root DQL automatics
        foreach ($joins as $key => $value) {
            $qb->leftJoin($value, $key);
        }

        /////////////////////////////////////////////////
        // Get main query
        $query = $object->getQuery();

        // Load hints
        if (!$disableRequest && ($hints = $request->query->get('@hints'))) {
            $hints = json_decode($hints);
            foreach($hints as $k => $v) {
                $query->setHint($k, $v);
            }

            // $query->setHint(Query::HINT_FORCE_PARTIAL_LOAD, true);
        }

        // Show DQL
        if (!$disableRequest && $request->query->get('@showDQL')) {
            throw new ValidatorException('DQL: '. $qb->getDQL());
        }

        // Check sorter
        if(!$disableRequest && $request->query->get('@sort')) {
            $filterError = true;
        }

        // Request enabled and no filter error, query builder return
        if(!$disableRequest && !$filterError) {
            if($select || $groupBy) {
                return $query->getResult();
            }
            else {
                return $object;
            }
        }

        // Filter error
        else {
            // Find result
            if($select || $groupBy) {
                throw new ValidatorException('Filter error from grouping by or selection.');
            }
            else {
                $entities = $query->getResult();
            }

            if(!$disableRequest) {
                // Legacy filter
                if ($filter = $request->query->get('@filter')) {
                    // Backup filter
                    $entities = array_filter(
                        $entities,
                        function ($entity) use ($filter) {
                            try {
                                $expressionLanguage = new ExpressionLanguage();
                                return $expressionLanguage->evaluate(
                                    $filter,
                                    array_merge(
                                        ['entity' => $entity],
                                        $this->externalExpressionValues()
                                    )
                                );
                            } catch (\Exception $e) {
                                return false;
                            }
                        }
                    );
                }

                // Legacy sorter
                if ($sorter = $request->query->get('@sort')) {
                    usort(
                        $entities,
                        function ($x, $y) use ($sorter) {
                            try {
                                $expressionLanguage = new ExpressionLanguage();
                                return $expressionLanguage->evaluate(
                                    $sorter,
                                    array_merge(
                                        ['x' => $x, 'y' => $y],
                                        $this->externalExpressionValues()
                                    )
                                );
                            } catch (\Exception $e) {
                                return false;
                            }
                        }
                    );
                }
            }

            return $entities;
        }
    }


    /**
     * @return mixed
     */
    public function new()
    {
        return new $this->data_class();
    }


    /**
     * @param $object
     * @param array|null $data
     * @throws \ReflectionException
     */
    public function updateWithoutListener($object, array $data)
    {
        // TODO: CAN UPDATE ONLY, CREATE IS NOT WORK HERE.

        if (empty($object)) {
            $this->logger->error('Object error, original data: '. json_encode($data));
            throw new ValidatorException('Update object cannot be null');
        }
        else {
            // Get object for updating or create an object
            $object = $object->getId() ? $this->get($object) : $object;
        }

        if (!empty($data)) {
            // Create query builder
            $em = $this->container->get('doctrine.orm.entity_manager');
            $qb = $em->createQueryBuilder()
                ->update(get_class($object), 'entity')
                ->where('entity = :entity')
                ->setParameter('entity', $object)
            ;

            foreach ($data as $key => $val) {
                $qb->set("entity.$key", ":$key")
                    ->setParameter($key, $val);
            }

            // Save
            $qb->getQuery()->execute();
        }
        else {
            throw new ValidatorException('Data cannot be empty');
        }

        return $this->get($object->getId());
    }


    /**
     * @param $object
     * @param array|null $data
     * @return bool
     * @throws ORMException
     * @throws OptimisticLockException
     * @throws \ReflectionException
     */
    public function update($object, array $data = null)
    {
        if (empty($object)) {
            $this->logger->error('Object error, original data: '. json_encode($data));
            throw new ValidatorException('Update object cannot be null');
        }
        else {
            // Get object for updating or create an object
            $object = $object->getId() ? $this->get($object) : $object;
        }

        if (!empty($data)) {
            $serializer = $this->container->get('serializer');
            $docReader = new AnnotationReader();

            try {
                $reflect = new \ReflectionClass(get_class($object));

                foreach ($data as $key => $val) {
                    if (!$reflect->hasProperty($key) /*|| !is_numeric($val)*/) {
                        // the entity does not have a such property
                        continue;
                    }
                    $annotations = $docReader->getPropertyAnnotations($reflect->getProperty($key));
                    foreach ($annotations as $annotation) {
                        if (
                            $annotation instanceof ManyToOne ||
                            $annotation instanceof OneToOne
                        ) {
                            $dataClass = $annotation->targetEntity;
                            $rep = $this->em->getRepository($dataClass);

                            $entity = null;
                            if ($val && empty($entity = $rep->find($val))) {
                                throw new NotFoundHttpException("The entity of key[$key] is not found");
                            } else {
                                $setter = 'set' . ucfirst($key);
                                $object->$setter($entity);

                                // clear data
                                unset($data[$key]);
                            }
                            break;
                        }
                        elseif(
                            $annotation instanceof ManyToMany ||
                            $annotation instanceof OneToMany
                        ) {
                            $dataClass = $annotation->targetEntity;
                            $rep = $this->em->getRepository($dataClass);

                            // compare origin and new arrays
                            $ucfirst = ucfirst($key);
                            $getter = "get$ucfirst";
                            $entities = $object->$getter() ?? new ArrayCollection();
                            $entitiesIds = $entities->map(function ($entity) {
                                return $entity->getId();
                            })->toArray();

                            // get removes array and adds array
                            $removes = array_values(array_diff($entitiesIds, $val));
                            $adds = array_values(array_diff($val, $entitiesIds));

                            // generate adder and remover
                            $singularize = ucfirst(Inflect::singularize($key));
                            $adder = "add$singularize";
                            $remover = "remove$singularize";

                            foreach ($removes as $remove) {
                                $entity = $rep->find($remove);
                                $object->$remover($entity);
                            }
                            foreach ($adds as $add) {
                                if (empty($entity = $rep->find($add))) {
                                    throw new NotFoundHttpException("The entity of key[$key] is not found");
                                } else {
                                    $object->$adder($entity);
                                }
                            }

                            // clear data
                            unset($data[$key]);
                        }
                        else {
                            if (property_exists(get_class($annotation), 'type')) {
                                if (
                                    $annotation->type === 'datetime' ||
                                    $annotation->type === 'date' ||
                                    $annotation->type === 'time'
                                ) {
                                    $setter = 'set' . ucfirst($key);
                                    $object->$setter(new \DateTime($val));

                                    // clear data
                                    unset($data[$key]);
                                }
                            }
                        }
                    }
                }
            } catch (\ReflectionException $e) {
                $this->logger->error('Save entity error: '.$e->getMessage());
                return false;
            } catch (\Exception $e) {
                $this->logger->error('Object error, original data: '. json_encode($data));
                throw $e;
            }

            // de-normalize json to object.
            $serializer->deserialize(
                json_encode($data),
                get_class($object),
                'json',
                [
                    'object_to_populate' => $object
                ]
            );
        }

        $validator = $this->container->get('validator');
        $errors = $validator->validate($object);
        if (count($errors) > 0) {
            /*
             * Uses a __toString method on the $errors variable which is a
             * ConstraintViolationList object. This gives us a nice string
             * for debugging.
             */
            $errorsString = (string)$errors;
            throw new ValidatorException($errorsString);
        }

        // Save
        try {
            $this->em->persist($object);
            $this->em->flush();
        }
        catch (UniqueConstraintViolationException $ex) {
            throw new ValidatorException('Duplication entries');
        }
        catch (\Exception $exception) {
            throw $exception;
        }

        return $object;
    }

    /**
     * @param $object
     * @return bool
     * @throws ORMException
     */
    public function remove($object): bool
    {
        $object = $this->get($object);

        $this->em->remove($object);
        try {
            $this->em->flush();
            return true;
        } catch (\Exception $e) {
            return false;
        }
    }
}